The Async/Await Journey
Coming back to JavaScript after a hiatus, async/await felt familiar but I quickly realized there's so much more to it than just replacing .then() chains. Today I'm diving deep into patterns that make asynchronous code more robust and efficient.
Why Async/Await Matters
Async/await makes asynchronous code look synchronous, but understanding the patterns underneath is crucial for writing maintainable applications.
Basic Syntax
// Old way with promises
fetch("/api/users")
.then(response => response.json())
.then(users => console.log(users))
.catch(error => console.error(error))
// New way with async/await
async function getUsers() {
try {
const response = await fetch("/api/users")
const users = await response.json()
console.log(users)
} catch (error) {
console.error(error)
}
}Essential Patterns
1. Sequential Execution
When operations depend on each other:
async function createUserProfile(userId) {
const user = await fetchUser(userId)
const profile = await createProfile(user)
const settings = await initializeSettings(profile.id)
return settings
}Use case: Each step needs the result from the previous one.
2. Parallel Execution
When operations are independent:
async function loadDashboard(userId) {
// All these run in parallel - much faster!
const [user, posts, notifications] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchNotifications(userId),
])
return { user, posts, notifications }
}Performance tip: Use Promise.all() for independent operations. It fails fast if any promise rejects.
3. AllSettled Pattern
When you want all results regardless of failures:
async function loadUserData(userId) {
const results = await Promise.allSettled([
fetchUser(userId),
fetchPosts(userId),
fetchSettings(userId), // This might fail, but others continue
fetchPreferences(userId),
])
const user = results[0].status === "fulfilled" ? results[0].value : null
const posts = results[1].status === "fulfilled" ? results[1].value : []
// Handle each result individually
}Use case: Partial failures shouldn't break the entire operation.
Error Handling Strategies
Pattern 1: Try-Catch with Specific Errors
async function processPayment(orderId) {
try {
const order = await fetchOrder(orderId)
const payment = await processPayment(order)
await sendConfirmationEmail(payment)
return payment
} catch (error) {
if (error instanceof PaymentError) {
// Handle payment-specific errors
await logPaymentFailure(orderId, error)
} else if (error instanceof NetworkError) {
// Handle network errors differently
await retryPayment(orderId)
} else {
// Unexpected errors
throw error
}
}
}Pattern 2: Result Wrapper
Instead of throwing errors, return a result object:
async function safeFetch(url) {
try {
const response = await fetch(url)
const data = await response.json()
return { success: true, data }
} catch (error) {
return { success: false, error: error.message }
}
}
// Usage
const result = await safeFetch("/api/users")
if (result.success) {
console.log(result.data)
} else {
console.error(result.error)
}Pattern 3: Error Boundaries
async function fetchWithRetry(url, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return await response.json()
} catch (error) {
if (i === retries - 1) throw error
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)))
}
}
}Advanced Patterns
Concurrent Processing with Limits
Process items in batches to avoid overwhelming the server:
async function processItems(items, limit = 3) {
const results = []
for (let i = 0; i < items.length; i += limit) {
const batch = items.slice(i, i + limit)
const batchResults = await Promise.all(batch.map(item => processItem(item)))
results.push(...batchResults)
}
return results
}Rate Limiting
Control how fast you make requests:
class RateLimiter {
constructor(requestsPerSecond) {
this.requestsPerSecond = requestsPerSecond
this.lastRequestTime = 0
this.minInterval = 1000 / requestsPerSecond
}
async wait() {
const now = Date.now()
const timeSinceLastRequest = now - this.lastRequestTime
if (timeSinceLastRequest < this.minInterval) {
await new Promise(resolve =>
setTimeout(resolve, this.minInterval - timeSinceLastRequest)
)
}
this.lastRequestTime = Date.now()
}
}
// Usage
const limiter = new RateLimiter(5) // 5 requests per second
async function fetchWithRateLimit(url) {
await limiter.wait()
return fetch(url)
}Timeout Pattern
Add timeouts to prevent hanging operations:
async function fetchWithTimeout(url, timeoutMs = 5000) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(url, { signal: controller.signal })
clearTimeout(timeoutId)
return response
} catch (error) {
clearTimeout(timeoutId)
if (error.name === "AbortError") {
throw new Error("Request timeout")
}
throw error
}
}Real-World Example: Data Pipeline
Here's a complete example combining multiple patterns:
async function processUserUpload(userId, fileIds) {
const limiter = new RateLimiter(10) // 10 requests/second
const results = []
// Process files in batches of 5
for (let i = 0; i < fileIds.length; i += 5) {
const batch = fileIds.slice(i, i + 5)
const batchResults = await Promise.allSettled(
batch.map(async fileId => {
await limiter.wait()
try {
const file = await fetchWithTimeout(`/api/files/${fileId}`, 3000)
const processed = await processFile(file, userId)
await saveProcessedFile(processed)
return { fileId, success: true, processed }
} catch (error) {
await logError(fileId, error)
return { fileId, success: false, error: error.message }
}
})
)
results.push(...batchResults)
}
// Summary
const successful = results.filter(
r => r.status === "fulfilled" && r.value.success
).length
console.log(`Processed ${successful}/${fileIds.length} files`)
return results
}Common Pitfalls and Solutions
Pitfall 1: Accidental Sequential Execution
// Slow - runs sequentially
async function slow() {
const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()
}
// Fast - runs in parallel
async function fast() {
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments(),
])
}Pitfall 2: Forgetting to Await
// Returns a Promise, not the actual value
function getData() {
return fetch("/api/data").then(res => res.json())
}
// Correct - use async/await
async function getData() {
const response = await fetch("/api/data")
return response.json()
}Pitfall 3: Unhandled Rejections in Loops
// If any fails, entire operation stops
async function processItems(items) {
for (const item of items) {
await processItem(item) // Stops on first error
}
}
// Handle errors per item
async function processItems(items) {
for (const item of items) {
try {
await processItem(item)
} catch (error) {
console.error(`Failed to process ${item.id}:`, error)
// Continue with next item
}
}
}Best Practices
- Always handle errors: Use try-catch or result wrappers
- Use Promise.all for parallel operations: Don't await sequentially when you don't need to
- Add timeouts: Prevent hanging operations
- Consider rate limiting: Don't overwhelm external APIs
- Use Promise.allSettled: When partial failures are acceptable
- Document async functions: Make it clear what they return
What I Learned
Async/await isn't just syntactic sugar. Understanding these patterns has helped me:
- Write more efficient code (parallel execution)
- Handle errors more gracefully
- Build more robust systems (retries, timeouts, rate limiting)
- Debug async code more effectively
The key insight: async/await makes code readable, but understanding concurrency patterns makes it performant and reliable.
Next Steps
I want to explore:
- Web Workers for CPU-intensive tasks
- Async iterators and generators
- RxJS for reactive programming patterns
- Server-sent events and WebSockets
The async journey continues! 🚀
Key Takeaways:
- Use
Promise.all()for parallel independent operations - Use
Promise.allSettled()when partial failures are OK - Always handle errors appropriately
- Add timeouts and rate limiting where needed
- Profile your code to avoid premature optimization